Skip to content

feat(api): MCP OAuth client registration (CIMD and DCR), RFC-0044 phase 3a (#144) - #178

Merged
Madraka merged 7 commits into
mainfrom
feat/mcp-oauth-client-registration
Nov 9, 2026
Merged

Madraka merged 7 commits into
mainfrom
feat/mcp-oauth-client-registration

Conversation

@Madraka

@Madraka Madraka commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

RFC-0044 Phase 3a (#144): remote MCP clients can now get a client_id themselves, so they can connect by URL. Clients with a Client ID Metadata Document use CIMD (Claude Code, Codex, VS Code). Clients without one use dynamic client registration as a limited fallback (Cursor). The Phase 2 test-only pre-registered client is gone, and set_embargo through an OAuth connection now files an approval request.

Phase 2 invariants are unchanged: generation binding, provider cleanup on revoke and on member removal, narrowing by installation role, DPoP refused. The Phase 2 suites (mcp-oauth-server.test.ts: server + hardening) now run against a DCR-registered client and pass unchanged, apart from the two assertions that encoded "no registration yet".

Items → code → tests

# Item Code Tests
1 CIMD: cimd() from @better-auth/cimd 1.7.18 (new catalog entry, pins test extended), MCP 2026-07-28 profile, cache of 500 entries revalidated within 60 min packages/api/src/auth/oauth.ts (plugin), auth/cimd-fetch.ts (transport adapter), auth/oauth-cimd-policy.ts (allow-list + fetch budget) testing-mcp-oauth-registration.ts › "Client ID Metadata Documents"
1a Two transports, both over the media import's safeFetch (RFC-0004 §5.7): Node nodeTransport() (resolve once, every address public, pinned), Workers workersTransport() (DoH checks). Rules: https on 443 only, DNS names only (no IP literals), no internal names, no redirects, 5 KB, 5 s, GET only packages/cli/src/commands/studio.ts (studioOauth), packages/cloudflare/src/admin-worker.ts (adminAuth); core-ts SafeFetchResult.headers (the plugin reads cache headers) stubbed transport: a good document all the way to /mcp; wrong client_id; a private or loopback DNS answer and an IP literal (nothing fetched); oversized (6 KB); a 302; a server that never answers (refused in < 5 s)
1b Never fetches from the deployment's own host or any site domain (blockedHosts, read lazily per fetch) same "never fetches from the deployment's own hosts or its sites' domains"
1c client_id = document URL (the plugin's check); client_id_metadata_document_supported advertised auth/oauth.ts metadata assertions in the CIMD and DCR-off cases
1d Redirect URIs: https on a non-loopback host, or http on exactly localhost/127.0.0.1/[::1] with any port (RFC 8252 §7.3, the provider's port-agnostic matching); at most 10; enforced at every authorization through validateRedirectUri auth/redirect-policy.ts, auth/oauth.ts "loopback redirect matching" (any port on loopback; wrong host, path, case, query or scheme refused; non-loopback exact); a CIMD document's refused URI (myapp://…) is never used; linear-rule unit checks incl. 100k-char inputs
2 DCR: Cloudhal's POST /api/auth/oauth2/register, mounted before Better Auth's catch-all. Checks the request, then calls the provider's registerOAuthClient server-side with empty headers. The provider's own endpoint is unreachable over HTTP: the fence opens exactly that path only while DCR is on, and a before hook refuses any HTTP call to it auth/dcr.ts, auth/dcr-server.ts, oauth-server.ts (mountClientRegistration, REGISTER_PATH), app.ts (mountAuthHandler), auth/instance.ts (hook) "registers a public client that connects to /mcp"; "never reaches the provider's own endpoint over HTTP, even past createApi"; fence variants (/register/, %72egister) in testing-mcp-oauth-server.ts
2a On by default; CLOUDHAL_MCP_DCR=off removes the route and registration_endpoint auth/oauth-registration.ts (oauthRegistrationFrom) "is off with CLOUDHAL_MCP_DCR=off; with CIMD off too, unknown clients are refused"
2b Rate limits in rate_limits (#173): 10 per address per hour, 300 per installation per day, invalid attempts counted, a client-sent X-Forwarded-For ignored auth/dcr.ts (StaffStore.hit) "limits registrations per address per hour and per installation per day"; "never takes a client-sent X-Forwarded-For"
2c Public clients only (none; when absent it becomes none, so no secret is ever minted); client_name 1–100; grant_types reduced to authorization_code/refresh_token; response_types ["code"]; 5 KB body (413) auth/dcr.ts (registrationBody) "takes public clients with https or loopback redirect URIs only" (12 refusals, 413, dropped fields)
2d Cap: 5,000 dynamically registered clients, 503 beyond auth/oauth-registration.ts (countDcrClients) "refuses registrations beyond the cap with 503"
2e Pruning: a DCR client registered over 30 days ago, with no live connection and no connection made, used or revoked in 30 days, is deleted with all its provider rows in one batch. Runs as the hourly oauth.clients.prune system job on the admin Worker's tick and on Node's auth/oauth-registration.ts (pruneDcrClients), oauth-prune-job.ts, cloudflare/src/scheduler.ts, cli/src/commands/scheduler-local.ts "prunes clients nobody used for 30 days, never one with a live connection" (through the job, refresh tokens gone, a pruned client unknown)
2f Optional allow-list CLOUDHAL_MCP_CLIENT_HOSTS: hosts a CIMD document may come from and a DCR client may redirect to (localhost admits loopback). A list that does not parse turns registration off and logs why auth/oauth-registration.ts, auth/redirect-policy.ts, auth/cimd-discovery.ts "fetches only from allowed hosts…" (CIMD), "takes only redirect hosts on CLOUDHAL_MCP_CLIENT_HOSTS…" (DCR)
2g Unknown clients refused when CIMD and DCR are both off (no discovery, no route) DCR-off case: a made-up id and a document URL are both refused
3 Phase 2 test-only client removed. OauthServer.createClient and auth/oauth-clients.ts are deleted; clientPrivileges/resourcePrivileges now admit nobody. The harness registers through DCR (registerClient) auth/types.ts, auth.ts, testing-oauth-harness.ts every Phase 2 suite case
4 set_embargo from a grant principal files the gate-only release as a schedule_release request (on-approval, bound to rl1:), so it shows up in Studio's Approvals and as a schedule-approval-due notification. Approving puts the embargo live with the person's approval. Without out-of-band approvals the old "with next publish" answer stays mcp-server/src/schedule-tools.ts (gateRequest), gate-approval.ts (GRANT_GATE_PENDING); gate helpers moved to schedule-gates.ts (size ratchet) mcp-server/test/schedules.test.ts (request filed, release awaiting, no person approval); api/src/testing-schedules.ts "an OAuth connection's set_embargo waits for a person…" (notification, approve → scheduled with approval.kind: person) on sqlite + D1
5 Shared suite on sqlite and D1 packages/api/src/testing-mcp-oauth-registration.ts, packages/api/test/mcp-oauth-registration.test.ts 18 cases × 2 backends
6 Hardening guide: switches, CIMD/DCR limits, redirect rules, the allow-list, when to turn DCR off, BETTER_AUTH_SECRET rotation (it encrypts the signing key: delete the jwks rows after rotating; checked in better-auth 1.7.18 jwt/sign.mjs) docs/guides/cloudflare-hardening.md § "Remote MCP clients (OAuth)"; docs/guides/self-host.md table; deploy/docker/compose.yml + .env.example pass the four CLOUDHAL_MCP_* variables (CLOUDHAL_MCP_OAUTH was not passed before) —

Also: ADR-0045 gains a "Phase 3a" section (decision 6 amended). The AS metadata now lists token_endpoint_auth_methods_supported: ["none", "private_key_jwt"] as RFC §2 says (the provider also listed the shared-secret methods). Size baseline tightened (schedule-tools.ts 1579 → 1553 lines, createApi 160 → 157). There is a patch changeset for @cloudhal/api, cloudflare, cli, mcp-server and core-ts. The playground deny list already covers /api/auth/*, so the new route is refused there.

Deviations from the RFC (each the more conservative option, or forced by the platform)

  1. DCR takes public clients only (none). RFC §3 also allowed private_key_jwt; the brief says public only. CIMD keeps none and private_key_jwt, as RFC §3 says.
  2. DCR drops logo_uri, client_uri, tos_uri, policy_uri, contacts, jwks* and software_*. The RFC said "stored but never rendered". Nothing self-asserted is stored.
  3. Private-use redirect schemes are limited for both paths, at every authorization (review round 1): reverse-domain schemes and known app schemes (cursor, vscode, CLOUDHAL_MCP_REDIRECT_SCHEMES) only, matched exactly. The RFC's §3 DCR rule ("https or loopback") would have locked out Cursor, the client DCR exists for.
  4. Per-address DCR limit in Cloudhal's rate_limits (as the brief asks), not Better Auth's rateLimit.customRules. The provider's own limiter never sees the server-side call.
  5. Pruning keeps a client for 30 days after its last connection activity (made, used or revoked), not only 30 days after registration. A client with a live connection is never pruned, however idle: ending a connection is the member's call.
  6. CIMD fetch is stricter than the plugin's transport contract. Port 443 only, no IP literals or internal names, no conditional revalidation (every refetch is a full 200 the plugin validates again). Fetches are budgeted across isolates in rate_limits (30 per origin and 120 in all per minute), because a Worker rebuilds the plugin, and so its budget, on every request.
  7. The Workers transport is the media import's workersTransport() from @cloudhal/core-ts/media, not a new one in packages/cloudflare. It has the same rules plus DoH address checks.
  8. New switch CLOUDHAL_MCP_CIMD=off. The RFC had only the DCR switch; the brief's "when both CIMD and DCR are off" needs one.
  9. CLOUDHAL_MCP_CLIENT_HOSTS also limits every client's redirect hosts, at every authorization. The RFC applied it to CIMD document hosts only. A list that does not parse turns registration off rather than failing every request.
  10. No cloudhal mcp clients create (RFC: "Phase 3 if cheap"). Removing createClient was cheaper and leaves no server-side client path.
  11. set_embargo through a connection files a request. It does not get a grant-bound auto approval, which would be a contract change (the release auto approval names a keyId).
  12. Worker settings are documented as wrangler secret put, because cloudhal deploy rewrites the admin Worker's plain vars on every deploy. A cloudhal.json key for them would be a public-interface change, left for a later RFC.

Left for PR B, as planned: Studio "Connected agents", the McpClientSetup tab (it will need /api/instance to say whether CIMD and DCR are on), docs/guides/mcp-oauth.md, the docs site's clients page, the README auth section, and the e2e test.

Review round 1 (commits 032397d, d6bb838, be319b8): the seven upheld findings

Each item has a test that fails on d363160. I checked by running the new tests against that commit, with a one-line shim for the new module.

# Finding Fix Test (fails on d363160)
1 Document (CIMD) client rows grew without bound Cap of 5,000 stored document clients: a new document gets temporarily_unavailable (503) beyond it. pruneCimdClients deletes a document client with no live connection and no fetch or connection activity for 1 day; the next authorize stores it again. The hourly oauth.clients.prune job prunes DCR clients in even UTC hours and document clients in odd hours, so it keeps a cost of 6. It now runs on the :17 watermark, so it always runs after the analytics rollup and the comments job (due jobs run by time, then name). It waits a minute when a free plan's tick is full; at :41 it could take the comments job's slot. Row helpers moved to auth/oauth-client-rows.ts bounds › "caps stored document clients and prunes unused ones"
2 Anonymous callers could drain the CIMD fetch budget; Workers refetched on every getClient auth/cimd-discovery.ts wraps the plugin's discovery. A stored client whose row was validated within 60 min (updatedAt) is served with no fetch on every runtime. Budgets are spent only before a real fetch: 20/min per caller address (the trusted header, #165/#169) for every fetch; 30/min per origin and 120/min in all only for documents not stored yet. When a budget is spent, a stored client is served from its row (stale-if-exhausted). A failed refetch of a stored client is still refused. oauth-cimd-policy.ts is removed bounds › "keeps stored clients connecting and refreshing while anonymous calls drain the budgets": drains both budgets from fake origins, then a stored Claude Code client authorizes and refreshes on a second deployment over the same database (a Worker's next request) with no fetch, then with the caller's own budget spent too
3 Cursor could not register Private-use schemes are accepted when the scheme is reverse-domain (has a dot) or on the known list (cursor, vscode, plus CLOUDHAL_MCP_REDIRECT_SCHEMES). Matched exactly (the provider relaxes only the loopback port). Always refused: javascript, vbscript, data, file, about, blob, filesystem, view-source, jar, intent, chrome, mailto, tel, sms, ftp, http…/ws look-alikes, and anything that is not scheme:/…. One bad entry refuses the registration; nothing is dropped. With a host list set, only known schemes pass bounds › "registers Cursor with its real body…": Cursor 3.14.27's observed body (forum.cursor.com/t/167608: cursor://anysphere.cursor-mcp/oauth/callback, https://www.cursor.com/agents/mcp/oauth/callback, http://localhost:8787/callback, none, no application_type) registers and authorizes; ten dangerous or unknown schemes refused; reverse-domain and vscode:// accepted; the operator list and the host-list rule
4 The host list was not checked at authorize for stored clients validateRedirectUri now applies the redirect rules and CLOUDHAL_MCP_CLIENT_HOSTS to every client at every authorization. Document hosts off the list resolve to no client, stored or not bounds › "applies the client host list at every authorization, to clients registered before it"
5 DCR off still let stored DCR clients authorize A before hook on /oauth2/authorize refuses a DCR-registered client (clientDiscoveryId, userId and referenceId all null) while CLOUDHAL_MCP_DCR=off. Tokens and refresh keep working until revoked or pruned. The guide says so bounds › "refuses new authorizations of DCR clients once DCR is off; their connections keep working"
6 Consent-screen identity auth/client-identity.ts: names shown plain (C0/C1 controls, bidi controls, zero-width, tag and other format characters removed, space runs collapsed) and at most 100 code points, in consent info and the stored grant. ConsentInfo.client gains kind (document / self-registered / server) and lookalike (a self-registered name containing Claude, Claude Code, ChatGPT, Codex, VS Code, Visual Studio Code or Cursor). Studio: a document client's host is in the page title ("Connect {client} from {host}") and leads the identity card; DCR clients read "Self-registered, not verified", with an alert when the name is a look-alike. Three new oauthConsent strings, en only, then pnpm i18n:export / pnpm i18n:import: the 18 other languages are machine drafts awaiting review (#112) bounds › "shows a client's name plain and short, its kind, and flags look-alike self-registrations"; apps/studio/test/oauth-consent.test.tsx (title with host; the self-registered label and the look-alike warning)
7 A pending set_embargo request never notified approvalDue in routes/notifications.ts: an on-approval release awaiting approval is due for as long as its request waits testing-schedules.ts › "an OAuth connection's set_embargo waits…" now advances the clock 3 h between filing and reading

Docs: the hardening guide's client section is rewritten for these rules. The self-host table, compose and .env.example add CLOUDHAL_MCP_REDIRECT_SCHEMES. ADR-0045 "Phase 3a" is updated. Changeset updated.

Review round 2 (commits 6a45959, 06cd53f; verification of be319b8): two blocking items and six more

Each fix has a test that fails on be319b8. I checked this in a temporary worktree at that commit, with the shim export for guardedResolve. The exception is item 8, a guard test: the provider already refuses that case, and the test keeps a provider upgrade from widening it.

# Finding Fix Test (fails on be319b8)
1 Blocking: on Node, the plugin's in-process fetch windows (30/origin, 120 in all, 16 concurrent) last the life of the process. Junk URLs on claude.ai could drain them, so a stored Claude Code row older than 60 min then failed with 429 guardedCimd sets the plugin's metadataFetchPolicy out of reach and turns its cache off (metadataRevalidationInterval: 0). The stored row is the only cache, and Cloudhal's rate_limits budgets are the only budgets, on every runtime. If the plugin still declines to fetch (temporarily_unavailable), a stored client younger than 24 h is served from its row bounds › "keeps a stored client refreshing on the same process after junk URLs on its origin". This uses the same instance: Date advanced 2 h, 30 junk URLs on claude.ai from 10 addresses, then the refresh. On be319b8 it fails with "metadata fetch rate limit exceeded for client origin"
2 Blocking: the CIMD cap locked out new clients for days At the cap, a new document evicts the oldest client with no live connection and no fetch in the last 10 min (evictCimdClient, one atomic batch), else 503. Hosts on CLOUDHAL_MCP_CLIENT_HOSTS and the well-known document hosts (claude.ai, claude.com, chatgpt.com, openai.com, vscode.dev, github.com, subdomains included) are exempt from the cap and from eviction. Grantless document clients are pruned after 3 hours (was a day), in up to 4 batches of 50 per hourly run while batches come back full. DCR and CIMD are pruned every run (cost 30, still on :17 so the job sorts after the comments job) bounds › "makes room for a well-known client's first connection when the cap is full of junk": cap of 3, filled with junk; a fourth junk document gets 503 while Claude Code's first connection succeeds; aged junk is evicted one at a time
3 Stale-if-exhausted had no age limit and the caller could trigger it The caller's own per-address budget comes first for every fetch, and past it that caller gets 429, never a stale row. A failed refetch is recorded on the row (markCimdFetchFailed: validation time cleared) and the client is refused, refresh included, until a fetch succeeds. A stale row is served only when the plugin declines and the row is under maxStaleHours (24) bounds › "follows a changed document after the freshness window and drops a removed one" (moved redirect_uris take effect, the old one is refused; a 404 document refuses refresh, is recorded on the row, and stays refused after the caller drains its budget); bounds › "serves a stored row the plugin declines to refetch only within 24 hours, never after a failure" (unit: 2 h served, 25 h refused, a fetch failure marks and refuses); the budget test now expects 429 for the drained caller
4 No index for the cap count; the count ran before any budget Migration 0032_oauth_client_kind_index: CREATE INDEX "oauthClient_clientDiscoveryId_updatedAt_idx" ON "oauthClient" ("clientDiscoveryId", "updatedAt"). 0031 is untouched. The count now runs only for a document not stored yet, after the per-address hit storage/test/migrate.test.ts › "0032…": EXPLAIN QUERY PLAN of the cap count uses the index; the migration name lists are extended
5 CIMD-off cuts existing CIMD connections Kept as is (conservative) and documented in the hardening guide's switch table next to DCR-off. The guide also now says that removing a document host from the list ends those clients at once — (behaviour unchanged, docs only)
6 Consent screen A private-use redirect is redirect.app (the scheme) and reads "Sends you back to an app on this computer (cursor:)", never as a web host. The title puts the host first: "{host}: connect {client}". At most 2 combining marks per base character. Look-alike checks compare names after NFKC plus a small confusable map (Cyrillic and Greek look-alikes of Latin, 0/O, 1/l/I; a full UTS #39 skeleton is a follow-up). The look-alike banner fires only for the CIMD well-known names (Claude, Claude Code, ChatGPT, Codex, VS Code), and not when the client is a document on that client's real host. Cursor's own name no longer triggers it. The banner text now says the return address is "shown above". Strings in en, then pnpm i18n:export / pnpm i18n:import (machine drafts) bounds › "shows a client's name plain and short, its kind, and flags look-alikes of document clients" (Cyrillic Сlaude, fullwidth, C1aude, CIaude, ChatGРT, VS Сode flagged; real Claude Code on claude.ai and DCR "Cursor" not; a "Claude" document on another host flagged; combining marks capped; the cursor: redirect); Studio › title with the host first, and the private-use redirect line
7 Stale notifications after the request's TTL approvalDue reads the linked request: an on-approval release is due only while its request is pending and unexpired testing-schedules.ts › "stops telling deciders about an OAuth connection's embargo once its request expired" (present at +23 h, gone at +25 h)
8 No test for application_type: web with a private-use redirect — bounds › Cursor test: web with cursor://… and with com.example.app:/cb is refused with invalid_redirect_uri (guard test, passes on be319b8 by design)

Also: cimd-guard.ts (the guard's wiring) and the WhoIsAsking component were extracted to stay under the size ratchet. ADR-0045 Phase 3a items 1 and 7 are rewritten. The changeset adds @cloudhal/storage.

Review round 3 (commit 03f6b95; verification of 06cd53f)

# Finding Fix Test
1 Blocking: the eviction exemption used clientId LIKE 'https://%.host/%'. In SQLite % spans /, so https://junk.example/x.claude.ai/c.json counted toward the cap but was never evicted evictCimdClient reads up to 50 of the oldest grantless candidates and judges each by new URL(clientId).hostname with the same isExempt admission uses. It then deletes the pick in one atomic batch that re-checks the predicate (clientId IN (?)). No LIKE over the URL is left bounds › "evicts by the client's real host…": the cap is filled with https://junkN.example.com/x.claude.ai/c.json and a third-party client still connects (fails on 06cd53f)
2 The plugin's per-origin and per-client fetch state is capped at maxCacheEntries (500) and refuses at capacity maxCacheEntries: 10_000 — (state capacity; the bounds of the guard itself are tested)
3 The prune job ignored cimdLimits.unusedHours oauthClientsPruneJob(sql, log, { dcrUnusedDays, cimdUnusedHours }). The runtimes keep the defaults, since these limits have no environment variable bounds › "prunes more than one batch…": 120 rows in one run, with 1 h instead of the default 3 (fails on 06cd53f)
4 Test gap — bounds › "never evicts a client with a live connection or a well-known host's, however old": both rows aged a day, a new junk document gets 503
5 Accented and other-script look-alikes slipped through folded() applies NFKD and drops combining marks, so Cláude and Clȧude match. Armenian, Cherokee and Latin small-capital look-alikes are added to the map. Follow-up for #144: a full UTS #39 confusable skeleton bounds › identity: Cláude, Clȧude, ᴄʟᴀᴜᴅᴇ, Ꮯlaude, Clаսde are flagged (fails on 06cd53f)
6 Shared rate-limit bucket behind a proxy Self-host guide: a warning to always set CLOUDHAL_BEHIND_PROXY=1 behind a reverse proxy, pointing to the hardening section —

Verification (local, one package at a time)

After review round 3 (HEAD 03f6b95): @cloudhal/api (the registration file runs 62 cases on better-sqlite3 and D1) and @cloudhal/studio re-run green, as are typecheck 20/20, lint, size check and design lint. The other packages are unchanged since round 2.

After review round 2 (HEAD 06cd53f):

  • turbo run test --concurrency=1, one package at a time, all green: @cloudhal/storage (migration 0032 on better-sqlite3 and D1), @cloudhal/api (better-sqlite3 and Miniflare D1; the registration test file runs 56 cases), @cloudhal/mcp-server, @cloudhal/studio, @cloudhal/cloudflare, @cloudhal/cli.
  • Round 2's new tests fail on be319b8, except item 8's guard test (see above). Round 1's ten fail on d363160.
  • @cloudhal/core-ts (only SafeFetchResult.headers changed, in round 0): 59 of 61 files green locally, fetcher.test.ts included. test/store.test.ts drops Miniflare connections locally (connect EADDRNOTAVAIL; every sqlite case passes). Left to CI.
  • turbo run typecheck --concurrency=1: 20/20. pnpm lint: no errors. pnpm size:check: ok. pnpm design:lint: clean.

🤖 Generated with Claude Code

Madraka and others added 7 commits November 9, 2026 22:57
…se 3a (#144)

Remote MCP clients can now get a client_id themselves.

- CIMD: @better-auth/cimd 1.7.18 with the MCP 2026-07-28 profile behind
  cimdFetch, which puts the media import's SSRF-safe safeFetch behind the
  plugin's fetch (nodeTransport on Node, workersTransport on Workers;
  https/443, DNS names only, public addresses, no redirects, 5 KB, 5 s,
  never the deployment's own host or a site domain). Fetch budget across
  isolates in rate_limits; optional CLOUDHAL_MCP_CLIENT_HOSTS allow-list.
- DCR: Cloudhal's POST /api/auth/oauth2/register in front of the provider
  (public clients only, 10/address/hour and 300/day in rate_limits, 5 KB,
  a cap of 5,000, unknown fields dropped). The provider's own endpoint
  stays unreachable over HTTP. Unused clients are pruned by the hourly
  oauth.clients.prune job on both schedulers. CLOUDHAL_MCP_DCR=off and
  CLOUDHAL_MCP_CIMD=off switch them.
- Redirect URIs: https, or loopback http on any port (RFC 8252), checked
  at registration and at every authorization.
- The Phase 2 server-side test client (OauthServer.createClient) is
  removed; the suites register through DCR or CIMD.
- set_embargo through an OAuth connection files the gate-only release for
  a person's approval.
- AS metadata lists none and private_key_jwt as token auth methods.
- Shared suite on sqlite and D1; hardening guide, self-host table, Docker
  compose variables, ADR-0045 Phase 3a section; changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…reen (#144)

Review of #178, finding 6. A client name is self-asserted (a metadata
document's or a registration's client_name), so:

- consent info and the stored grant show it plain: control, bidi,
  zero-width, tag and other format characters removed, space runs
  collapsed, at most 100 code points (client-identity.ts, linear);
- ConsentInfo.client gains kind (document, self-registered, server) and
  lookalike: a self-registered name containing Claude, Claude Code,
  ChatGPT, Codex, VS Code or Cursor;
- Studio leads with a document client's host, in the title too
  ("Connect {client} from {host}"), labels DCR clients "Self-registered,
  not verified", and warns about look-alike names.

Three new oauthConsent strings in en; the 18 other languages are machine
drafts from pnpm i18n:import, awaiting review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…its (#144)

Review of #178, finding 7. A set_embargo from an OAuth connection files
an on-approval release, whose execute time is the moment it was filed,
so the 60-minute window before it never matched once a millisecond had
passed. approvalDue() makes an on-approval release awaiting approval due
for as long as it waits. The test now reads notifications three hours
after filing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…he host list and DCR switch at authorize (#144)

Review of #178, findings 1-5.

1. Document clients are capped (5,000; 503 temporarily_unavailable for a
   new one beyond it) and pruned after a day with no connection and no
   fetch (pruneCimdClients); the hourly oauth.clients.prune job prunes
   DCR and document clients in alternate hours, on the :17 watermark.
2. cimd-discovery.ts wraps the plugin's discovery: a stored client
   validated within 60 min is served from its row with no fetch on every
   runtime; budgets are spent only before a real fetch (20/min per
   trusted caller address; 30/min per origin and 120/min in all only for
   documents not stored yet); a stored client is served from its row when
   a budget is spent. oauth-cimd-policy.ts is removed.
3. Private-use redirect schemes are accepted for native and public
   clients when reverse-domain or known (cursor, vscode,
   CLOUDHAL_MCP_REDIRECT_SCHEMES), matched exactly; dangerous schemes and
   http look-alikes are always refused; one bad entry refuses the whole
   registration. Cursor 3.14.27's observed DCR body registers.
4. validateRedirectUri applies CLOUDHAL_MCP_CLIENT_HOSTS to every client
   at every authorization; document hosts off the list resolve to none.
5. With CLOUDHAL_MCP_DCR=off, /oauth2/authorize refuses clients DCR
   stored earlier; their existing connections keep working.

New shared suite testing-mcp-oauth-bounds.ts on sqlite and D1; each case
fails on d363160. Hardening guide, self-host table, compose and
.env.example, ADR-0045 and the changeset updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…ding (#144)

Review round 2 of #178, item 7. approvalDue now reads the release's
linked approval request: an on-approval release awaiting approval is due
only while that request is pending and unexpired. After the request's
24 h TTL nobody can approve it, so nobody keeps a notification for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…und 2 (#144)

Review round 2 of #178.

1. The plugin's own fetch windows are set out of reach and its cache is
   off, so the stored row is the only cache and Cloudhal's rate_limits
   budgets the only budgets, on Node's long-lived instance too. A
   declined plugin fetch serves a stored row younger than 24 h.
2. At the cap a new document evicts the oldest grantless client not
   fetched for 10 min (else 503). The host list and the well-known
   document hosts (claude.ai, claude.com, chatgpt.com, openai.com,
   vscode.dev, github.com) are exempt from the cap and from eviction.
   Grantless document clients are pruned after 3 h, in up to four
   batches of 50 per run; DCR and CIMD are pruned every run.
3. The caller's own budget comes first and answers 429, never a stale
   row. A failed refetch is recorded on the row and refuses the client
   until a fetch succeeds.
4. Migration 0032_oauth_client_kind_index on
   oauthClient (clientDiscoveryId, updatedAt). The cap count runs only
   for a new document, after the per-address hit.
5. CIMD-off keeps cutting existing document connections (conservative);
   documented next to DCR-off.
6. Consent screen: a private-use redirect reads as an app on this
   computer; the title puts the host first; at most two combining marks
   per character; look-alikes are compared after NFKC plus a small
   confusable map, and flagged only for the CIMD well-known names, and
   not when the client is a document on that client's real host. New
   strings in en; the other languages are machine drafts.
8. Test: application_type web with a private-use redirect is refused.

Each fix has a test that fails on be319b8, except 8, which guards a
provider rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
… hardening (#144)

Review round 3 of #178.

1. Eviction matched exempt hosts with `clientId LIKE 'https://%.host/%'`.
   In SQLite `%` spans `/`, so https://junk.example/x.claude.ai/c.json
   passed for claude.ai and was never evicted, which brought the cap
   lockout back. evictCimdClient now reads the oldest grantless
   candidates and judges each by `new URL(clientId).hostname` with the
   same isExempt as admission. It deletes the pick in one atomic batch
   that re-checks the predicate (`clientId IN (?)`).
2. The plugin's cache size, which also bounds its per-origin and
   per-client fetch state, goes from 500 to 10,000.
3. oauthClientsPruneJob takes the deployment's limits (dcrUnusedDays,
   cimdUnusedHours). Test: 120 rows pruned in one run.
4. Test: eviction never removes a client with a live grant, nor a
   well-known host's row older than evictMinutes.
5. Look-alike names are folded with NFKD and their combining marks
   dropped (Cláude, Clȧude), with Armenian, Cherokee and Latin small
   capital look-alikes added to the map. A UTS #39 skeleton stays a
   follow-up.
6. Self-host guide: behind a reverse proxy without
   CLOUDHAL_BEHIND_PROXY=1, every client shares the proxy's rate-limit
   bucket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
@Madraka
Madraka merged commit 5bf5bd4 into main Nov 9, 2026
13 checks passed
@Madraka
Madraka deleted the feat/mcp-oauth-client-registration branch November 9, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant